Skip to content

feat: implement Toolbar component - #5043

Open
k0ndee wants to merge 8 commits into
callstack:mainfrom
k0ndee:@k0ndee/toolbar
Open

feat: implement Toolbar component#5043
k0ndee wants to merge 8 commits into
callstack:mainfrom
k0ndee:@k0ndee/toolbar

Conversation

@k0ndee

@k0ndee k0ndee commented Aug 11, 2026

Copy link
Copy Markdown

Motivation

Introduce a new Toolbar component implementing the Material Design 3 toolbars spec. Reuse theme tokens (shape, color roles, elevation) and follow the same component-tokens pattern as FAB / Checkbox.

Spec re-check (M3 toolbars)

Re-checked the M3 toolbars specs:

  • Two variants: floating (self-positioned pill, corner.full, elevation level 3) and docked (full-width bar pinned to the bottom edge, corner.none, no elevation, extends into safe-area insets).
  • floating supports horizontal/vertical orientation; docked is always horizontal per spec.
  • Default (standard) colors: container/unselected-button surfaceContainer, icon/label onSurfaceVariant, selected button secondaryContainer with onSecondaryFixedVariant (light) / onSecondaryContainer (dark) icon.
  • vibrant colorScheme: container/unselected-button primaryContainer, icon/label onPrimaryFixedVariant (light) / onPrimaryContainer (dark), selected button falls back to surfaceContainer with onSurface icon.
  • Direct IconButton/Button children are auto-recolored to match colorScheme unless they already set their own color (a mode on either opts them out in favor of their own mode-based coloring).

Changes

Toolbar / tokens / utils

  • New src/components/Toolbar/{Toolbar.tsx,tokens.ts,utils.ts}: variant (floating/docked), orientation (horizontal/vertical, floating-only), colorScheme (standard/vibrant), containerColor override, style/contentContainerStyle, testID, aria-label, theme, ref
  • Container shape/elevation/spacing resolved from ToolbarTokens; docked extends into safe-area insets via margin outside Surface's box, keeping the 64dp icon band untouched
  • withToolbarChildColors auto-recolors mode-less IconButton/Button children per colorScheme, without touching children that already set their own color/mode
  • Exported from src/index.tsx

Example / docs / tests

  • Example screen (ToolbarExample.tsx) covering both variants, both orientations, both color schemes, over a scrollable list
  • Docs page + prop table + theme colors table; screenshots for floating (horizontal/vertical) and docked, each in standard/vibrant
  • Unit tests (Toolbar.test.tsx) covering shape/elevation per variant, color resolution across light/dark themes for both color schemes, and child auto-recoloring behavior

Scope note

Per Satyajit Sahoo's recommendation, this implementation is split into a series of PRs for easier review, rather than landing as one large change. This PR is PR 1 of the series:

  • PR 1 (this PR) — Floating + docked variant, static (without animation, no FAB pairing), colorScheme standard/vibrant. Component, tokens, tests, docs, example.
  • PR 2 — Show/hide animation (offscreen slide + spring), generic scroll shared-value primitive.
  • PR 3 — FAB pairing, FAB/key-action collapse on scroll.
  • PR 4 — Trailing-edge overflow menu.

Related issue

Related to #4988

Test plan

  • yarn typecheck / yarn lint / Toolbar unit tests
  • Full unit test suite green
  • Manually verified in the example app: floating (horizontal/vertical) and docked, standard/vibrant, over scrolling content, light and dark themes

Visual verification

standard vibrant
floating, horizontal floating-h-standard floating-h-vibrant
floating, vertical floating-v-standard floating-v-vibrant
docked docked-standard docked-vibrant

Docs screenshots: docs/public/screenshots/toolbar_*.png.

@k0ndee k0ndee changed the title @k0ndee/toolbar feat: implement Toolbar component Aug 11, 2026

@MikitasK MikitasK left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

nice work! 👍 overall, this looks like a solid foundation for the new Toolbar components
just a few points to address before merge:

Comment thread src/components/Toolbar/tokens.ts Outdated
Comment thread src/components/Toolbar/utils.ts Outdated
Comment thread src/components/Toolbar/Toolbar.tsx Outdated

@MikitasK MikitasK left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

LGTM 👍

Comment thread src/components/Toolbar/utils.ts Outdated
Comment on lines +81 to +131
React.Children.map(children, (child) => {
if (!React.isValidElement<RecolorableProps>(child)) {
return child;
}

// `React.Children.map` doesn't flatten a `Fragment`, so recurse into
// it manually.
if (child.type === React.Fragment) {
return React.cloneElement(
child,
undefined,
recolorChildren(child.props.children, theme, colorScheme)
);
}

if (child.type === IconButton) {
// A `mode` or explicit color prop means it already has its own
// spec-defined coloring.
if (
child.props.mode != null ||
child.props.iconColor != null ||
child.props.containerColor != null
) {
return child;
}

const { iconColor, containerColor } = resolveIconColors({
theme,
colorScheme,
selected: child.props.selected ?? false,
});
return React.cloneElement(child, {
iconColor,
containerColor,
...(child.props.selected ? { mode: 'contained-tonal' } : null),
});
}

if (child.type === Button) {
// `text` is `Button`'s mode-less default; any other mode (its own
// spec-defined coloring) or an explicit color prop opts it out.
if (
(child.props.mode != null && child.props.mode !== 'text') ||
child.props.textColor != null ||
child.props.buttonColor != null
) {
return child;
}

const textColor = resolveLabelColor({ theme, colorScheme });
return React.cloneElement(child, { textColor });

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

React.Children and cloneElement are both off-limits in v6 — see #4989, which is open specifically to unwind them where they hurt composition, with the FAB Menu migration in #4963 as the reference. The occurrences still in src/ (Card, CardActions, Appbar/utils.ts, Dialog, ToggleButtonRow) are what that issue exists to remove, so they aren't precedent for new code.

Beyond the rule, the traversal has the failure mode context exists to avoid: it only matches Fragment, IconButton and Button, and returns everything else untouched. Write <Toolbar><MyBoldButton /></Toolbar>, or wrap two actions in a View to group them, and the recolouring silently stops — the children keep IconButton's default onSurfaceVariant on the toolbar's container.

Copy link
Copy Markdown
Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I added a ToolbarColorContext and modified Button and IconButton to pick up the Toolbar colors. Now they will work regardless of how deep they are nested inside any Views or custom components.

Comment thread src/components/Toolbar/Toolbar.tsx Outdated
containerColor,
style,
contentContainerStyle,
testID = 'toolbar',

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

No default testIDs in new components — Switch.tsx:58 is the v6 reference.

-  testID = 'toolbar',
+  testID,

:198 and :237 derive ${testID}-content / -container, so those need to become conditional too.

Copy link
Copy Markdown
Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I removed the default testID

Comment thread docs/src/data/themeColors.ts Outdated
Comment on lines +332 to +342
iconColor:
'theme.colors.onSecondaryFixedVariant (light) / theme.colors.onSecondaryContainer (dark)',
},
},
vibrant: {
unselected: {
backgroundColor: 'theme.colors.primaryContainer',
iconColor:
'theme.colors.onPrimaryFixedVariant (light) / theme.colors.onPrimaryContainer (dark)',
textColor:
'theme.colors.onPrimaryFixedVariant (light) / theme.colors.onPrimaryContainer (dark)',

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Toolbar.standard.selected, lines 332-333:

-          iconColor:
-            'theme.colors.onSecondaryFixedVariant (light) / theme.colors.onSecondaryContainer (dark)',
+          iconColor: 'theme.colors.onSecondaryContainer',

Toolbar.vibrant.unselected, lines 339-342:

-        iconColor:
-          'theme.colors.onPrimaryFixedVariant (light) / theme.colors.onPrimaryContainer (dark)',
-        textColor:
-          'theme.colors.onPrimaryFixedVariant (light) / theme.colors.onPrimaryContainer (dark)',
+        iconColor: 'theme.colors.onPrimaryContainer',
+        textColor: 'theme.colors.onPrimaryContainer',

tokens.ts:49,57,59 already resolve these unconditionally, and there's no *-fixed-variant token anywhere in md.comp.toolbar.*. Then yarn docs generate — the same strings are inlined at Toolbar.mdx:160 and need committing alongside. The other two rows in the block are already correct.

Copy link
Copy Markdown
Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Good catch, I forgot to udpate the docs when I changed tokens. Now they are correct.

Comment thread src/components/Toolbar/tokens.ts Outdated
Comment on lines +16 to +24
containerShape: 'none' as ShapeToken,
containerLeadingSpace: 16,
containerTrailingSpace: 16,
defaultSpacing: 32,
} as const;

const floating = {
containerHeight: 64,
containerShape: 'full' as ShapeToken,

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

as casts aren't used anywhere in src/ — and these two do nothing anyway. ShapeToken is keyof ThemeShapeCorners | 'none' | 'full', so the literal is already assignable; the cast only widens it and loses the literal under as const.

-  containerShape: 'none' as ShapeToken,
+  containerShape: 'none',
-  containerShape: 'full' as ShapeToken,
+  containerShape: 'full',

Deleting them is the whole fix — no satisfies needed, the values are checked at the usage site in resolveCornerRadius. Same at Toolbar.test.tsx:392: type the it.each table as a const instead of casting it.

Copy link
Copy Markdown
Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I removed casts.

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

3 participants